diff --git a/docs/iterations/edit-project/README.md b/docs/iterations/edit-project/README.md index f1f37b2c..fb94c737 100644 --- a/docs/iterations/edit-project/README.md +++ b/docs/iterations/edit-project/README.md @@ -28,20 +28,23 @@ ## Навигация по плану -| Файл | Содержимое | -|------|------------| -| [architecture.md](architecture.md) | Архитектура, безопасность, state, IPC API, сервисы, компоненты, CM6, shortcuts, CSS | -| [iter-0-refactoring.md](iter-0-refactoring.md) | PR 0: Обязательные рефакторинги R1-R4 (отдельный PR) | -| [iter-1-walking-skeleton.md](iter-1-walking-skeleton.md) | Итерация 1: Read-only файловый браузер | -| [iter-2-editable-save.md](iter-2-editable-save.md) | Итерация 2: Editable CodeMirror + сохранение | -| [iter-3-multi-tab-crud.md](iter-3-multi-tab-crud.md) | Итерация 3: Multi-tab + создание/удаление файлов | -| [iter-4-search-shortcuts.md](iter-4-search-shortcuts.md) | Итерация 4: Горячие клавиши, поиск, UX polish | -| [iter-5-git-watching.md](iter-5-git-watching.md) | Итерация 5: Git status, file watching, conflict detection | -| [file-list.md](file-list.md) | Риски, бенчмарки, полный список файлов | +| # | Файл | Содержимое | +|---|------|------------| +| — | [architecture.md](architecture.md) | Архитектура, безопасность, state, IPC API, сервисы, компоненты, CM6, shortcuts, CSS | +| 0 | [iter-0-refactoring.md](iter-0-refactoring.md) | PR 0: Обязательные рефакторинги R1-R4 (отдельный PR) | +| 1 | [iter-1-walking-skeleton.md](iter-1-walking-skeleton.md) | Итерация 1: Read-only файловый браузер | +| 2 | [iter-2-editable-save.md](iter-2-editable-save.md) | Итерация 2: Editable CodeMirror + сохранение | +| 3 | [iter-3-multi-tab-crud.md](iter-3-multi-tab-crud.md) | Итерация 3: Multi-tab + создание/удаление файлов | +| 4 | [iter-4-search-shortcuts.md](iter-4-search-shortcuts.md) | Итерация 4: Горячие клавиши, поиск, UX polish | +| 5 | [iter-5-git-watching.md](iter-5-git-watching.md) | Итерация 5: Git status, file watching, conflict detection | +| — | [file-list.md](file-list.md) | Риски, бенчмарки, полный список файлов | +| — | [research-tasks.md](research-tasks.md) | 5 исследовательских задач (все COMPLETED) | +| — | [wireframes-draft.md](wireframes-draft.md) | ASCII wireframes (DRAFT, пересмотр позже) | ## Общая статистика -- **Новые файлы**: ~30 -- **Модификации**: ~17 существующих файлов +- **Новые файлы**: ~35 +- **Модификации**: ~18 существующих файлов - **Тесты**: ~15 новых тестовых файлов - **Итерации**: 6 (PR 0 + 5 итераций) +- **Ресёрч**: 5/5 завершён (R1-R5, см. [research-tasks.md](research-tasks.md)) diff --git a/docs/iterations/edit-project/architecture.md b/docs/iterations/edit-project/architecture.md index 277c64de..7ae49b1a 100644 --- a/docs/iterations/edit-project/architecture.md +++ b/docs/iterations/edit-project/architecture.md @@ -68,6 +68,9 @@ src/renderer/components/team/editor/ ├── EditorShortcutsHelp.tsx # Модальное окно shortcuts (кнопка ?) └── GitStatusBadge.tsx # M/U/A бейджи в дереве (итерация 5) +src/renderer/utils/ +└── editorBridge.ts # Module-level singleton: Store ↔ CM6 refs bridge (R3) + src/renderer/components/common/ └── FileTree.tsx # Generic FileTree с render-props (рефакторинг из ReviewFileTree) ``` @@ -214,6 +217,8 @@ export interface EditorSlice { editorFileTreeLoading: boolean; editorFileTreeError: string | null; + editorExpandedDirs: Set; // Сохраняется при re-open (H7) + openEditor: (projectPath: string) => Promise; closeEditor: () => void; // closeEditor() выполняет полный cleanup: @@ -247,7 +252,7 @@ export interface EditorSlice { // В store -- только dirty flags, loading и статусы сохранения. // ═══════════════════════════════════════════════════ editorFileLoading: Record; // per-file loading indicator - editorModifiedFiles: Set; // dirty markers (НЕ содержимое!) + editorModifiedFiles: Record; // dirty markers (НЕ содержимое!). Record вместо Set — Zustand не отслеживает мутации Set editorSaving: Record; editorSaveError: Record; @@ -259,7 +264,7 @@ export interface EditorSlice { saveAllFiles: (getContent: (filePath: string) => string | null) => Promise; // CodeMirrorEditor передаёт callback: saveAllFiles((fp) => stateCache.current.get(fp)?.doc.toString() ?? null) discardChanges: (filePath: string) => void; - hasUnsavedChanges: () => boolean; // derived getter + hasUnsavedChanges: () => boolean; // Object.keys(editorModifiedFiles).length > 0 // ═══════════════════════════════════════════════════ // Группа 4: File operations (итерация 3) @@ -282,6 +287,52 @@ interface EditorFileTab { } ``` +### Store ↔ Component Bridge (R3 — решение) + +`editorBridge.ts` — module-level singleton для связи Zustand store и React refs CodeMirrorEditor. + +```typescript +// src/renderer/utils/editorBridge.ts +import type { EditorState, EditorView } from '@codemirror/state'; + +let stateCache: Map | null = null; +let scrollTopCache: Map | null = null; +let activeView: EditorView | null = null; + +export const editorBridge = { + /** Вызывается CodeMirrorEditor при mount */ + register(sc: Map, stc: Map, view: EditorView) { + stateCache = sc; scrollTopCache = stc; activeView = view; + }, + /** Вызывается CodeMirrorEditor при unmount */ + unregister() { stateCache = null; scrollTopCache = null; activeView = null; }, + /** Для saveFile() — контент из кешированного state */ + getContent(filePath: string): string | null { + return stateCache?.get(filePath)?.doc.toString() ?? null; + }, + /** Для saveAllFiles() — контент всех modified файлов */ + getAllModifiedContent(modifiedFiles: Set): Map { + const result = new Map(); + for (const fp of modifiedFiles) { + const content = stateCache?.get(fp)?.doc.toString(); + if (content !== undefined) result.set(fp, content); + } + return result; + }, + /** Для closeEditor() — полный cleanup */ + destroy() { + activeView?.destroy(); + stateCache?.clear(); + scrollTopCache?.clear(); + activeView = null; + }, + /** Обновить ссылку на view (при tab switch view пересоздаётся) */ + updateView(view: EditorView) { activeView = view; }, +}; +``` + +Паттерн аналогичен `ConfirmDialog.tsx` (module-level `globalSetState`) и `changeReviewSlice.ts` (module-level state). + ### EditorState pooling (Map в useRef) Контент файлов живёт ТОЛЬКО в CodeMirror EditorState. Один активный EditorView на весь редактор. @@ -396,7 +447,8 @@ interface ReadFileResult { interface GitFileStatus { path: string; - status: 'modified' | 'untracked' | 'staged' | 'deleted'; + status: 'modified' | 'untracked' | 'staged' | 'deleted' | 'conflict'; + // 'conflict' = merge conflicts (git porcelain codes UU, AA, DD) } interface SearchResult { @@ -681,6 +733,14 @@ interface EditorExtensionOptions { // const tabSizeCompartment = useRef(new Compartment()); // Причина: useRef гарантирует изоляцию если компонент монтируется дважды (React Strict Mode). // Паттерн из CodeMirrorDiffView.tsx:332-336 (langCompartment/mergeCompartment/portionCompartment в useRef). +// +// R2 ПОДТВЕРЖДЕНИЕ: Compartment — opaque identity token, sharing между EditorState безопасен. +// Подтверждено автором CM6 (Marijn Haverbeke): "Compartments can be shared without issue". +// Каждый EditorState хранит свой Map в config. +// reconfigure() на одном View НЕ влияет на cached states в пуле. +// EDGE CASE: при unmount+remount компонента — cached states ссылаются на старые Compartments. +// Решение: при remount создать новые Compartments, заново создать EditorState для АКТИВНОГО таба. +// Evicted LRU states: теряют undo history (ожидаемо), cursor через EditorSelection. function buildEditorExtensions(options: EditorExtensionOptions): Extension[] { return [ @@ -774,20 +834,53 @@ Diff-специфичные стили (`.cm-changedLine`, `.cm-deletedChunk`, ` ## Keyboard Shortcuts -| Shortcut | Действие | Итерация | -|----------|---------|----------| -| `Cmd+S` | Сохранить активный файл | 2 | -| `Cmd+Shift+S` | Сохранить все | 2 | -| `Cmd+W` | Закрыть активный tab | 3 | -| `Cmd+P` | Quick Open (fuzzy search файлов) | 4 | -| `Cmd+F` | Поиск в файле (CM6 search) | 2 | -| `Cmd+Shift+F` | Поиск по содержимому файлов | 4 | -| `Cmd+Shift+[` / `Cmd+Shift+]` | Переключение табов влево/вправо | 4 | -| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Переключение табов (MRU) | 4 | -| `Cmd+B` | Toggle file tree sidebar | 4 | -| `Cmd+G` | Go to line (CM6 gotoLine) | 4 | -| `Cmd+Z` / `Cmd+Shift+Z` | Undo/Redo (CM6 native) | 2 | -| `Escape` | Закрыть overlay (с confirm при unsaved) | 1 | +| Shortcut | Действие | Итерация | Конфликт | +|----------|---------|----------|----------| +| `Cmd+S` | Сохранить активный файл | 2 | — (CM6 keymap) | +| `Cmd+Shift+S` | Сохранить все | 2 | — | +| `Cmd+W` | Закрыть активный tab | 3 | `useKeyboardShortcuts.ts:155` | +| `Cmd+P` | Quick Open (fuzzy search файлов) | 4 | — | +| `Cmd+F` | Поиск в файле (CM6 search) | 2 | `useKeyboardShortcuts.ts:241` | +| `Cmd+Shift+F` | Поиск по содержимому файлов | 4 | — | +| `Cmd+Shift+[` / `Cmd+Shift+]` | Переключение табов влево/вправо | 4 | `useKeyboardShortcuts.ts:177` | +| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Переключение табов (MRU) | 4 | `useKeyboardShortcuts.ts:81` | +| `Cmd+B` | Toggle file tree sidebar | 4 | `useKeyboardShortcuts.ts:271` | +| `Cmd+G` | Go to line (CM6 gotoLine) | 4 | — | +| `Cmd+Z` / `Cmd+Shift+Z` | Undo/Redo (CM6 native) | 2 | — | +| `Escape` | Закрыть overlay (с confirm при unsaved) | 1 | — | + +### Scope Isolation (R1 — решение) + +6 из 12 шорткатов конфликтуют с глобальными в `useKeyboardShortcuts.ts`. Решение: + +**Approach A: Guard в глобальном handler** (надёжность 8/10) + +```typescript +// useKeyboardShortcuts.ts — добавить guard +const editorOpen = useStore(s => s.editorProjectPath !== null); + +// В handler (bubble phase, window.addEventListener('keydown')): +if (editorOpen) { + // Early return для конфликтующих shortcuts: + // Cmd+W, Cmd+B, Cmd+F, Cmd+Shift+[/], Ctrl+Tab + const isEditorConflict = (e.metaKey && ['w','b','f'].includes(e.key)) + || (e.metaKey && e.shiftKey && ['[',']'].includes(e.key)) + || (e.ctrlKey && e.key === 'Tab'); + if (isEditorConflict) return; +} +``` + +**Safety net: `stopPropagation` в CM6** — все editor keybindings с `stopPropagation: true`: + +```typescript +keymap.of([ + { key: 'Mod-f', run: openSearchPanel, stopPropagation: true }, + { key: 'Mod-s', run: () => { onSave?.(); return true; }, stopPropagation: true }, + // ... +]); +``` + +**Паттерн подтверждён**: `ChangeReviewDialog` уже использует capture-phase handler с guard (строки 379-408). Замечания: - `Cmd+[` / `Cmd+]` НЕ используются для табов -- это indent/outdent в CM6 и VS Code diff --git a/docs/iterations/edit-project/file-list.md b/docs/iterations/edit-project/file-list.md new file mode 100644 index 00000000..452ab482 --- /dev/null +++ b/docs/iterations/edit-project/file-list.md @@ -0,0 +1,137 @@ +# Риски, бенчмарки, полный список файлов + +## Риски + +| # | Риск | Вероятность | Импакт | Итерация | Митигация | +|---|------|------------|--------|----------|-----------| +| 1 | Path traversal через IPC | Средняя | Критический | 1+ | `validateFilePath()` на КАЖДОМ handler + module-level projectRoot | +| 2 | Symlink escape из projectRoot | Высокая | Критический | 1 | `fs.realpath()` + re-check на каждом entry в readDir | +| 3 | node_modules/огромные директории -- OOM | Высокая | Высокий | 1 | IGNORED_DIRS фильтр + MAX_DIR_ENTRIES=500 + виртуализация (итерация 4) | +| 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 фича) | +| 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 | +| 11 | Memory leak: 20+ EditorView | Высокая | Критический | 2 | EditorState pooling + LRU eviction | +| 12 | Zustand keystroke storm | Высокая | Высокий | 2 | Content вне store + debounced dirty flag | +| 13 | XSS через имена файлов | Низкая | Средний | 1 | React JSX + validateFileName() при создании | +| 14 | Запись в .git/ | Средняя | Высокий | 2 | `isGitInternalPath()` блокирует write | +| 15 | ~~review.ts без валидации пути~~ | ~~Существует~~ | ~~Критический~~ | **ИСПРАВЛЕНО** | `validateFilePath()` добавлен в handleSaveEditedFile (hotfix применён) | + +--- + +## Benchmarks + +``` +Benchmark 1: EditorView memory + Открыть 25 файлов x 200KB + Измерить: performance.memory.usedJSHeapSize + Порог: < 150MB + +Benchmark 2: Tab switch latency + Переключить таб (500KB файл с syntax highlighting) + Измерить: time from click to contentful paint + Порог: < 50ms + +Benchmark 3: File tree render + 5000+ файлов, все папки раскрыты (с виртуализацией) + Измерить: FPS при скролле + Порог: >= 55fps + +Benchmark 4: readDir latency + Директория с 5000 файлами + Измерить: time from click to tree displayed + Порог: < 200ms + +Benchmark 5: Keystroke re-renders + React DevTools Profiler при наборе текста + Порог: FileTreePanel и TabBar рендерятся 0 раз при наборе +``` + +--- + +## Полный список файлов + +### Новые файлы (~30) + +| # | Файл | Итерация | Описание | +|---|------|----------|----------| +| 1 | `src/shared/types/editor.ts` | 1 | Все типы editor | +| 2 | `src/main/services/editor/ProjectFileService.ts` | 1 | Stateless файловый сервис | +| 3 | `src/main/services/editor/index.ts` | 1 | Barrel export: `{ ProjectFileService }` (расширяется в итерациях 4-5) | +| 4 | `src/main/services/editor/FileSearchService.ts` | 4 | Search in files | +| 5 | `src/main/services/editor/GitStatusService.ts` | 5 | git status --porcelain | +| 6 | `src/main/services/editor/EditorFileWatcher.ts` | 5 | FileWatcher (~250-300 LOC, burst coalescing + ENOSPC fallback) | +| 7 | `src/main/services/editor/conflictDetection.ts` | 5 | Утилита mtime check: сравнение mtime до/после save, conflict resolution (~40 LOC) | +| 8 | `src/main/ipc/editor.ts` | 1 | IPC handlers | +| 9 | `src/main/ipc/ipcWrapper.ts` | 1 | Общий `createIpcWrapper()` | +| 10 | `src/main/utils/atomicWrite.ts` | 2 | Перемещение `atomicWriteAsync()` из `team/atomicWrite.ts` (randomUUID, fsync, EXDEV fallback) | +| 11 | `src/renderer/utils/fileTreeBuilder.ts` | 1 | buildTree (рефакторинг) | +| 12 | `src/renderer/utils/codemirrorLanguages.ts` | 1 | Языковой маппинг (рефакторинг) | +| 13 | `src/renderer/utils/codemirrorTheme.ts` | 1 | Базовая тема CM (рефакторинг) | +| 14 | `src/renderer/utils/tabLabelDisambiguation.ts` | 3 | Disambiguation дублей | +| 15 | `src/renderer/store/slices/editorSlice.ts` | 1 | Zustand slice (Группа 1: tree), расширяется в итерации 2-3 | +| 16 | `src/renderer/hooks/useEditorKeyboardShortcuts.ts` | 4 | Горячие клавиши | +| 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 | +| 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 | +| 24 | `src/renderer/components/team/editor/EditorEmptyState.tsx` | 1 | Empty state | +| 25 | `src/renderer/components/team/editor/EditorBinaryState.tsx` | 1 | Binary файлы | +| 26 | `src/renderer/components/team/editor/EditorErrorState.tsx` | 1 | Ошибки чтения | +| 27 | `src/renderer/components/team/editor/EditorContextMenu.tsx` | 3 | Context menu | +| 28 | `src/renderer/components/team/editor/NewFileDialog.tsx` | 3 | Inline-input | +| 29 | `src/renderer/components/team/editor/QuickOpenDialog.tsx` | 4 | Cmd+P dialog | +| 30 | `src/renderer/components/team/editor/SearchInFilesPanel.tsx` | 4 | Cmd+Shift+F | +| 31 | `src/renderer/components/team/editor/EditorBreadcrumb.tsx` | 4 | Breadcrumb | +| 32 | `src/renderer/components/team/editor/EditorShortcutsHelp.tsx` | 4 | Shortcuts modal | +| 33 | `src/renderer/components/team/editor/fileIcons.ts` | 4 | Иконки файлов | +| 34 | `src/renderer/components/team/editor/GitStatusBadge.tsx` | 5 | M/U/A/C(conflict) бейджи | +| 35 | `src/renderer/utils/editorBridge.ts` | 2 | Module-level singleton: Store ↔ CM6 refs bridge (R3) | + +### Модификации существующих файлов (~17) + +| # | Файл | Итерация | Изменение | +|---|------|----------|-----------| +| 1 | `src/preload/constants/ipcChannels.ts` | 1-5 | +12 констант EDITOR_* (включая EDITOR_CLOSE) | +| 2 | `src/preload/index.ts` | 1-5 | Секция `editor: { ... }` | +| 3 | `src/shared/types/api.ts` | 1-5 | `EditorAPI` interface | +| 4 | `src/main/ipc/review.ts` | 1 | Замена wrapReviewHandler на import из ipcWrapper | +| 5 | `src/main/utils/pathValidation.ts` | 1 | +validateFileName, +isDevicePath, +isGitInternalPath | +| 6 | `src/renderer/store/types.ts` | 1 | +EditorSlice в AppState | +| 7 | `src/renderer/store/index.ts` | 1 | +createEditorSlice | +| 8 | `src/renderer/components/team/TeamDetailView.tsx` | 1 | Кнопка "Open in Editor" + overlay state | +| 9 | `src/renderer/components/team/review/ReviewFileTree.tsx` | 1 | Рефакторинг: generic FileTree + fileTreeBuilder | +| 10 | `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | 1 | Рефакторинг: импорт из codemirrorLanguages/Theme | +| 11 | `src/main/ipc/handlers.ts` | 1 | +initializeEditorHandlers() + registerEditorHandlers(ipcMain) + removeEditorHandlers(ipcMain) | +| 12 | `src/renderer/api/httpClient.ts` | 1 | Stub для editor: EditorAPI (throw "not available in browser mode") | +| 13 | `src/main/ipc/teams.ts` | follow-up | Миграция wrapTeamHandler → createIpcWrapper (40+ замен, отдельный PR) | +| 14 | `src/shared/types/index.ts` | 1 | +`export type * from './editor'` (barrel re-export, паттерн как team/review/terminal) | +| 15 | `src/main/index.ts` | 5 | `mainWindow.on('closed')` → `cleanupEditorState()`. `shutdownServices()` → `cleanupEditorState()` | +| 16 | `src/renderer/index.css` | 2 | +editor CSS-переменные | +| 17 | `src/renderer/hooks/useKeyboardShortcuts.ts` | 4 | Guard `editorOpen` для 6 конфликтующих shortcuts (R1) | + +### Тесты (новые, ~15) + +| # | Файл | Итерация | +|---|------|----------| +| 1 | `test/main/services/editor/ProjectFileService.test.ts` | 1 | +| 2 | `test/main/ipc/editor.test.ts` | 1 | +| 3 | `test/main/ipc/ipcWrapper.test.ts` | 1 | +| 4 | `test/main/utils/atomicWrite.test.ts` | 2 | +| 5 | `test/renderer/utils/fileTreeBuilder.test.ts` | 1 | +| 6 | `test/renderer/utils/codemirrorLanguages.test.ts` | 1 | +| 7 | `test/renderer/store/editorSlice.test.ts` | 1 (расширяется в 2-3) | +| 8 | `test/renderer/utils/tabLabelDisambiguation.test.ts` | 3 | +| 9 | `test/renderer/components/team/editor/EditorContextMenu.test.ts` | 3 | +| 10 | `test/main/services/editor/FileSearchService.test.ts` | 4 | +| 11 | `test/renderer/hooks/useEditorKeyboardShortcuts.test.ts` | 4 | +| 12 | `test/renderer/components/team/editor/fileIcons.test.ts` | 4 | +| 13 | `test/main/services/editor/GitStatusService.test.ts` | 5 | +| 14 | `test/main/services/editor/EditorFileWatcher.test.ts` | 5 | +| 15 | `test/main/services/editor/conflictDetection.test.ts` | 5 | diff --git a/docs/iterations/edit-project/iter-0-refactoring.md b/docs/iterations/edit-project/iter-0-refactoring.md new file mode 100644 index 00000000..a92b8f04 --- /dev/null +++ b/docs/iterations/edit-project/iter-0-refactoring.md @@ -0,0 +1,87 @@ +# PR 0: Обязательные рефакторинги (R1-R4) + +> Перед итерацией 1. Отдельный PR. + +## Цель + +Обязательные рефакторинги -- без них будет дублирование кода. Выполняются ДО написания нового кода. Тесты `ReviewFileTree` и `CodeMirrorDiffView` должны проходить после рефакторинга (zero behavior change). + +## Почему отдельный PR + +R1 затрагивает production `ReviewFileTree` (используется в `ChangeReviewDialog`), R3 затрагивает production `CodeMirrorDiffView`. Объединение рефакторинга production-кода + 15 новых файлов в одну итерацию — чрезмерный blast radius (28 файлов). Разделение: +- **PR 0 ("Refactoring")**: R1-R4 + тесты. Мёрдж только после проверки что ChangeReviewDialog работает корректно. +- **PR 1 ("Walking Skeleton")**: Новые editor-файлы. Зависит от PR 0. + +## Рефакторинги + +| # | Что извлечь | Откуда | Куда | LOC | +|---|-------------|--------|------|-----| +| R1 | `buildTree()` + `collapse()` + сортировка | `ReviewFileTree.tsx:42-83` | `src/renderer/utils/fileTreeBuilder.ts` | ~50 | +| R2 | `getSyncLanguageExtension()` + `getAsyncLanguageDesc()` | `CodeMirrorDiffView.tsx:64-128` | `src/renderer/utils/codemirrorLanguages.ts` | ~70 | +| R3 | Базовая тема CM (без diff-стилей) | `CodeMirrorDiffView.tsx:158-198` | `src/renderer/utils/codemirrorTheme.ts` | ~40 | +| R4 | `wrapReviewHandler()` | `review.ts:133-145` | `src/main/ipc/ipcWrapper.ts` | ~15 | + +## Детали каждого рефакторинга + +### R1: `buildTree()` — Generic tree builder + +**NB**: `ReviewFileTree` работает с `FileChangeSummary` (имеет `status`, `additions`, `deletions`), а editor использует `FileTreeEntry` (имеет `size`, `children`). `buildTree()` должен быть generic по типу node, принимая `getPath: (item: T) => string` и `isDirectory: (item: T) => boolean` как параметры. + +```typescript +// src/renderer/utils/fileTreeBuilder.ts +function buildTree( + items: T[], + getPath: (item: T) => string, + isDirectory: (item: T) => boolean +): TreeNode[] +``` + +### R2: `getSyncLanguageExtension()` — Языковой маппинг + +Извлечь из `CodeMirrorDiffView.tsx:64-128`. 16+ языков синхронно + `@codemirror/language-data` async fallback. + +### R3: `baseEditorTheme` — Базовая тема + +**NB**: `diffTheme` — один `EditorView.theme({...})` на 125 строк. Рефакторинг: +1. Извлечь строки 158-198 в `baseEditorTheme = EditorView.theme({...})` в `codemirrorTheme.ts` +2. В `CodeMirrorDiffView.tsx` создать `const diffSpecificTheme = EditorView.theme({...})` со строками 199-283 +3. В `buildExtensions()` заменить `diffTheme` на `[baseEditorTheme, diffSpecificTheme]` + +### R4: `createIpcWrapper()` — Общий IPC wrapper + +**NB**: `teams.ts` имеет аналогичный `wrapTeamHandler` (40+ вызовов), но его миграция — отдельный follow-up PR после итерации 1. Blast radius слишком высокий (1755 строк) для совмещения с основной фичей. В итерации 1 R4 применяется ТОЛЬКО к `review.ts` + новому `editor.ts`. + +```typescript +// src/main/ipc/ipcWrapper.ts +export function createIpcWrapper(logPrefix: string) { + const log = createLogger(logPrefix); + return async function wrap(op: string, fn: () => Promise): Promise> { + try { return { success: true, data: await fn() }; } + catch (error) { + const msg = error instanceof Error ? error.message : String(error); + log.error(`handler error [${op}]:`, msg); + return { success: false, error: msg }; + } + }; +} + +// review.ts: +const wrapHandler = createIpcWrapper('IPC:review'); + +// editor.ts: +const wrapHandler = createIpcWrapper('IPC:editor'); +``` + +## После рефакторинга + +- `ReviewFileTree.tsx` импортирует `buildTree`, `TreeNode` из `fileTreeBuilder.ts` +- `CodeMirrorDiffView.tsx` импортирует из `codemirrorLanguages.ts` и `codemirrorTheme.ts` +- `review.ts` импортирует `createIpcWrapper` из `ipcWrapper.ts` +- `teams.ts` — миграция `wrapTeamHandler` → `createIpcWrapper` в отдельном follow-up PR (40+ замен, высокий blast radius) + +## Критерии готовности + +- `pnpm typecheck` проходит +- Тесты `ReviewFileTree` и `CodeMirrorDiffView` проходят (zero behavior change) +- ChangeReviewDialog работает корректно (manual check) +- Новые unit-тесты для `fileTreeBuilder.ts` и `ipcWrapper.ts` diff --git a/docs/iterations/edit-project/iter-1-walking-skeleton.md b/docs/iterations/edit-project/iter-1-walking-skeleton.md new file mode 100644 index 00000000..e3d24837 --- /dev/null +++ b/docs/iterations/edit-project/iter-1-walking-skeleton.md @@ -0,0 +1,113 @@ +# Итерация 1: Walking Skeleton (read-only файловый браузер) + +> Зависит от: [PR 0 (Рефакторинги)](iter-0-refactoring.md) + +## Цель + +Минимальный end-to-end вертикальный срез -- кнопка "Open in Editor" на TeamDetailView открывает полноэкранный overlay с деревом файлов слева и содержимым файла с подсветкой синтаксиса (read-only) справа. + +## Новые npm-зависимости + +`@codemirror/search` (`pnpm add @codemirror/search`) + +## IPC каналы + +| Канал | Описание | +|-------|----------| +| `editor:open` | Инициализировать editor, установить activeProjectRoot в module-level state | +| `editor:close` | Cleanup: сброс activeProjectRoot, остановка watcher | +| `editor:readDir` | Рекурсивное чтение директории (depth=1, lazy) | +| `editor:readFile` | Чтение содержимого файла с binary detection | + +## Новые файлы + +| # | Файл | Описание | +|---|------|----------| +| 1 | `src/shared/types/editor.ts` | `FileTreeEntry`, `ReadDirResult`, `ReadFileResult` | +| 2 | `src/main/services/editor/ProjectFileService.ts` | Stateless сервис: `readDir`, `readFile` с полной валидацией | +| 3 | `src/main/services/editor/index.ts` | Barrel export: `{ ProjectFileService }` (расширяется в итерациях 4-5) | +| 4 | `src/main/ipc/editor.ts` | IPC handlers с module-level `activeProjectRoot` | +| 5 | `src/main/ipc/ipcWrapper.ts` | Общий `createIpcWrapper()` (рефакторинг из review.ts) | +| 6 | `src/renderer/store/slices/editorSlice.ts` | Минимальный slice: Группа 1 (tree state + actions) | +| 7 | `src/renderer/utils/fileTreeBuilder.ts` | Generic `buildTree()` (рефакторинг из ReviewFileTree) | +| 8 | `src/renderer/utils/codemirrorLanguages.ts` | `getSyncLanguageExtension()` (рефакторинг) | +| 9 | `src/renderer/utils/codemirrorTheme.ts` | `baseEditorTheme` (рефакторинг) | +| 10 | `src/renderer/components/common/FileTree.tsx` | Generic FileTree с render-props | +| 11 | `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | Full-screen overlay | +| 12 | `src/renderer/components/team/editor/EditorFileTree.tsx` | Обёртка над generic FileTree | +| 13 | `src/renderer/components/team/editor/CodeMirrorEditor.tsx` | Read-only CM6 view (один EditorView, без pooling пока) | +| 14 | `src/renderer/components/team/editor/EditorEmptyState.tsx` | Нет открытых файлов | +| 15 | `src/renderer/components/team/editor/EditorBinaryState.tsx` | Заглушка для бинарных файлов | +| 16 | `src/renderer/components/team/editor/EditorErrorState.tsx` | Заглушка для ошибок чтения | + +## Изменения в существующих файлах + +| # | Файл | Изменение | +|---|------|-----------| +| 1 | `src/shared/types/api.ts` | `EditorAPI` interface + `editor: EditorAPI` в `ElectronAPI` | +| 2 | `src/shared/types/index.ts` | +`export type * from './editor'` (barrel re-export, паттерн как team/review/terminal) | +| 3 | `src/preload/constants/ipcChannels.ts` | `EDITOR_OPEN`, `EDITOR_CLOSE`, `EDITOR_READ_DIR`, `EDITOR_READ_FILE` | +| 4 | `src/preload/index.ts` | Секция `editor: { ... }` в `electronAPI` | +| 5 | `src/main/ipc/handlers.ts` | `initializeEditorHandlers` + `registerEditorHandlers` | +| 6 | `src/main/ipc/review.ts` | Заменить `wrapReviewHandler` на import из `ipcWrapper.ts` | +| 7 | `src/renderer/components/team/TeamDetailView.tsx` | Кнопка "Open in Editor" + state для overlay | +| 8 | `src/renderer/components/team/review/ReviewFileTree.tsx` | Рефакторинг: использовать generic FileTree + fileTreeBuilder | +| 9 | `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | Рефакторинг: импорт из codemirrorLanguages/Theme | +| 10 | `src/main/utils/pathValidation.ts` | Добавить `validateFileName()`, `isDevicePath()`, `isGitInternalPath()`. Экспортировать `matchesSensitivePattern()` (приватная) для `isSensitive` в readDir. **B1**: Экспортировать `isPathWithinRoot()` (приватная, строка ~30) — нужна для SEC-14 write-handler guard в iter-2 | +| 11 | `src/main/index.ts` | Добавить базовый cleanup в `mainWindow.on('closed')`: вызвать `cleanupEditorState()` (экспорт из editor.ts, сбрасывает `activeProjectRoot = null`). Без этого при Cmd+Q на macOS state "утечёт" и `editor:open` откажет при следующем открытии окна. Полный watcher cleanup — итерация 5, но базовый reset нужен с итерации 1 | +| 12 | `src/renderer/api/httpClient.ts` | Stub для `editor: EditorAPI` — throw "Editor is not available in browser mode" (паттерн как `review`, `terminal`, `teams`) | +| 13 | `src/renderer/store/types.ts` | `EditorSlice` в AppState | +| 14 | `src/renderer/store/index.ts` | `createEditorSlice` | + +## 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) + +## Performance-требования + +- MAX_ENTRIES_PER_DIR = 500; при превышении -- "N more files..." +- readFile тиерная стратегия: <256KB мгновенно, 256KB-2MB progress, 2MB-5MB preview, >5MB external +- Binary detection: null bytes в первых 8KB +- Дедупликация IPC: `Map>` для readFile + +## UX-требования + +- Focus management: при открытии -- фокус на первый файл. При закрытии -- вернуть фокус на кнопку. `inert` на фон +- ARIA: file tree сразу с `role="tree"`, `role="treeitem"`, `aria-expanded`, `role="group"` +- Пустой проект: "No files found" + кнопка Create (неактивна до итерации 3) +- Binary файлы: `EditorBinaryState.tsx` с кнопкой "Open in System Viewer" +- Max indent 12 уровней, tooltip на глубоких узлах + +## State management + +Создать минимальный `editorSlice` уже на итерации 1 с полями `editorProjectPath`, `editorFileTree`, `editorFileTreeLoading`, `editorFileTreeError`, `openEditor()`, `closeEditor()`, `loadFileTree()`, `expandDirectory()`. Это избавит от болезненной миграции useState → Zustand на итерации 2. Табы и dirty-состояние добавляются в slice на итерации 2. + +## Тестирование + +| # | Что тестировать | Файл | +|---|----------------|------| +| 1 | `ProjectFileService` -- чтение директории с mock fs, проверка security (reject paths outside projectRoot), исключение node_modules, symlink escape | `test/main/services/editor/ProjectFileService.test.ts` | +| 2 | `editorSlice` -- open/close editor, loadFileTree, expandDirectory | `test/renderer/store/editorSlice.test.ts` | +| 3 | `EditorFileTree` -- snapshot тесты рендеринга | — | +| 4 | `fileTreeBuilder.ts` -- unit тесты `buildTree()` (с generic типами для FileChangeSummary и FileTreeEntry) | `test/renderer/utils/fileTreeBuilder.test.ts` | +| 5 | `ipcWrapper.ts` -- unit тесты createIpcWrapper | `test/main/ipc/ipcWrapper.test.ts` | +| 6 | Manual: открыть TeamDetailView -> "Open in Editor" -> дерево загружается -> клик по файлу -> подсветка синтаксиса | — | + +## Критерии готовности + +- [ ] Кнопка видна на TeamDetailView рядом с путём проекта +- [ ] Overlay открывается по клику, закрывается по Escape или X +- [ ] Дерево файлов загружается для projectPath команды +- [ ] Клик по файлу показывает содержимое с синтаксической подсветкой +- [ ] Binary файлы показывают заглушку +- [ ] Попытка прочитать файл за пределами проекта -- отказ +- [ ] `pnpm typecheck` проходит +- [ ] Рефакторинги R1-R4 выполнены, тесты ReviewFileTree и CodeMirrorDiffView проходят + +## Оценка + +- **Надёжность решения: 8/10** -- CodeMirror 6 проверен в продакшене, все зависимости в проекте, паттерны повторяют ChangeReviewDialog. +- **Уверенность: 9/10** -- самый понятный этап, минимум неизвестных. diff --git a/docs/iterations/edit-project/iter-2-editable-save.md b/docs/iterations/edit-project/iter-2-editable-save.md new file mode 100644 index 00000000..b518a699 --- /dev/null +++ b/docs/iterations/edit-project/iter-2-editable-save.md @@ -0,0 +1,82 @@ +# Итерация 2: Editable CodeMirror + сохранение файлов + +> Зависит от: [Итерация 1](iter-1-walking-skeleton.md) + +## Цель + +Переключить CodeMirror из read-only в редактируемый режим. Cmd+S для сохранения. Индикатор unsaved changes. Status bar. + +## IPC каналы + +| Канал | Описание | +|-------|----------| +| `editor:writeFile` | Запись файла (atomic write через tmp + rename) | + +## Новые файлы + +| # | Файл | Описание | +|---|------|----------| +| 1 | `src/main/utils/atomicWrite.ts` | Перемещение существующего `atomicWriteAsync()` из `src/main/services/team/atomicWrite.ts` (shared utility). **H2**: Blast radius — ~10 source файлов + ~4 тестовых файла (TeamTaskWriter, TeamDataService, TeamKanbanManager, TeamAgentToolsInstaller, и их тесты). Обновить все импорты | +| 2 | `src/renderer/components/team/editor/EditorTabBar.tsx` | Панель вкладок (один файл пока, подготовка к multi-tab) | +| 3 | `src/renderer/components/team/editor/EditorStatusBar.tsx` | Ln:Col, язык, отступы | +| 4 | `src/renderer/components/team/editor/EditorToolbar.tsx` | Save, Undo, Redo | +| 5 | `src/renderer/utils/editorBridge.ts` | Module-level singleton: Store ↔ CM6 refs bridge (R3). Компонент вызывает `register()` при mount, store actions используют `getContent()`/`destroy()` | + +## Изменения в существующих файлах + +| # | Файл | Изменение | +|---|------|-----------| +| 1 | `src/shared/types/editor.ts` | Типы для write request/response | +| 2 | `src/shared/types/api.ts` | `writeFile` в `EditorAPI` | +| 3 | `src/main/services/editor/ProjectFileService.ts` | Метод `writeFile()` с atomic write | +| 4 | `src/main/ipc/editor.ts` | Handler `editor:writeFile` | +| 5 | `src/preload/index.ts` | `editor.writeFile` | +| 6 | `src/preload/constants/ipcChannels.ts` | `EDITOR_WRITE_FILE` | +| 7 | `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | Интеграция TabBar, StatusBar | +| 8 | `src/renderer/components/team/editor/CodeMirrorEditor.tsx` | Убрать readOnly, EditorState pooling (Map), Cmd+S keymap | +| 9 | `src/renderer/store/slices/editorSlice.ts` | Расширить: +Группа 2 (tabs) + Группа 3 (dirty/save) | +| 10 | `src/renderer/index.css` | +8 editor CSS-переменных (--editor-tab-active-bg, --editor-tab-modified-dot и др.) | + +## Security-требования + +1. `writeFile`: `validateFilePath()` ДО записи. **+ SEC-14**: `isPathWithinRoot(normalizedPath, activeProjectRoot)` для блокировки `~/.claude` writes. `Buffer.byteLength(content, 'utf8') <= 2MB`. Atomic write. Запрет записи в `.git/`. `activeProjectRoot` из module-level state (SEC-9, SEC-12) +2. Файл удалён извне при save: ENOENT -> inline-ошибка "File was deleted. Create new? / Close tab" (не падать) + +## Performance-требования + +- НЕ хранить modified content в Zustand. Контент только в EditorState CM. В store: `editorModifiedFiles: Record` (dirty flags — Record вместо Set, т.к. Zustand не отслеживает мутации Set) +- Dirty flag через debounced `EditorView.updateListener` (300ms) +- Гранулярные Zustand-селекторы: FileTreePanel не подписывается на tabs/content +- EditorState pooling: один EditorView, Map в useRef +- LRU eviction при > 30 states + +## UX-требования + +- Status bar: `[Ln 42, Col 15] | [TypeScript] | [UTF-8] | [Spaces: 2]` +- Unsaved changes при закрытии overlay: три кнопки ("Save All & Close" / "Discard & Close" / "Cancel") +- Dirty indicator (точка) на вкладке ПЕРЕД текстом +- `hasUnsavedChanges()` в slice + +## Тестирование + +| # | Что тестировать | Файл | +|---|----------------|------| +| 1 | `ProjectFileService.writeFile` -- запись с mock fs, reject для файлов вне проекта, atomic write | `test/main/services/editor/ProjectFileService.test.ts` (расширение) | +| 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 сбрасывается | — | + +## Критерии готовности + +- [ ] Файл редактируется в CodeMirror (не read-only) +- [ ] Cmd+S сохраняет файл через atomic write +- [ ] Dirty indicator на вкладке +- [ ] Status bar показывает позицию курсора и язык +- [ ] При закрытии overlay с unsaved changes -- confirmation dialog +- [ ] Benchmark: 0 re-render FileTreePanel/TabBar при наборе текста + +## Оценка + +- **Надёжность решения: 7/10** -- atomic write и EditorState pooling добавляют сложность. +- **Уверенность: 8/10** -- паттерны известны, но dirty tracking через CM6 updateListener требует тестирования. diff --git a/docs/iterations/edit-project/iter-3-multi-tab-crud.md b/docs/iterations/edit-project/iter-3-multi-tab-crud.md new file mode 100644 index 00000000..aaf544f4 --- /dev/null +++ b/docs/iterations/edit-project/iter-3-multi-tab-crud.md @@ -0,0 +1,82 @@ +# Итерация 3: Multi-tab + создание/удаление файлов + +> Зависит от: [Итерация 2](iter-2-editable-save.md) + +## Цель + +Поддержка нескольких открытых файлов во вкладках. Контекстное меню: создать файл/папку, удалить. Tab management. + +## Новые npm-зависимости + +`@radix-ui/react-context-menu` (`pnpm add @radix-ui/react-context-menu`) — для нативного контекстного меню. Проверить текущие `@radix-ui/*` версии в package.json и использовать совместимую. + +## IPC каналы + +| Канал | Описание | +|-------|----------| +| `editor:createFile` | Создать файл (validateFileName + валидация parentDir) | +| `editor:createDir` | Создать директорию | +| `editor:deleteFile` | Удалить файл через `shell.trashItem()` (безопасно) | + +## Новые файлы + +| # | Файл | Описание | +|---|------|----------| +| 1 | `src/renderer/components/team/editor/EditorContextMenu.tsx` | Context menu (New File, New Folder, Delete, Reveal in Finder) | +| 2 | `src/renderer/components/team/editor/NewFileDialog.tsx` | Inline-input для имени файла/папки | +| 3 | `src/renderer/utils/tabLabelDisambiguation.ts` | `getDisambiguatedTabLabel()` для дублей "index.ts" | + +## Изменения в существующих файлах + +| # | Файл | Изменение | +|---|------|-----------| +| 1 | `src/shared/types/editor.ts` | Типы для create/delete | +| 2 | `src/shared/types/api.ts` | `createFile`, `createDir`, `deleteFile` в EditorAPI | +| 3 | `src/main/services/editor/ProjectFileService.ts` | `createFile()`, `createDir()`, `deleteFile()` | +| 4 | `src/main/ipc/editor.ts` | 3 новых handler | +| 5 | `src/preload/index.ts` | 3 новых метода | +| 6 | `src/preload/constants/ipcChannels.ts` | `EDITOR_CREATE_FILE`, `EDITOR_CREATE_DIR`, `EDITOR_DELETE_FILE` | +| 7 | `src/renderer/components/team/editor/EditorTabBar.tsx` | Multi-tab: массив, переключение, close, middle-click close | +| 8 | `src/renderer/components/team/editor/EditorFileTree.tsx` | Right-click context menu, refresh после create/delete | +| 9 | `src/renderer/store/slices/editorSlice.ts` | Tab management actions, file operations | + +## Security-требования + +1. `createFile`: `validateFileName()` -- запрет `.`, `..`, control chars, path separators, NUL, length > 255. Валидировать и `parentDir`, и `path.join(parentDir, name)` (SEC-7) +2. `deleteFile`: `shell.trashItem()`, НЕ `fs.unlink()`. `validateFilePath()` обязательна +3. Confirmation dialog перед удалением + +## Performance-требования + +- Tab closing: `stateCache.delete(tabId)` (явная очистка памяти). closeAllTabs: `stateCache.clear()` +- Debounce обновления дерева после create/delete (500ms), не перечитывать после каждой операции + +## UX-требования + +- Disambiguation tab labels: два "index.ts" -> "(main/utils)" и "(renderer/utils)" +- Длинные имена: max-width ~160px, `truncate`, tooltip. Modified dot ПЕРЕД текстом +- ARIA для tab bar: `role="tablist"`, `role="tab"`, `aria-selected`, `role="tabpanel"` + +## Тестирование + +| # | Что тестировать | Файл | +|---|----------------|------| +| 1 | `ProjectFileService.createFile/deleteFile` с mock fs | `test/main/services/editor/ProjectFileService.test.ts` (расширение) | +| 2 | `editorSlice` -- multi-tab actions (open, close, reorder) | `test/renderer/store/editorSlice.test.ts` (расширение) | +| 3 | `tabLabelDisambiguation.ts` -- unit тесты | `test/renderer/utils/tabLabelDisambiguation.test.ts` | +| 4 | `EditorContextMenu` -- рендеринг, клики | `test/renderer/components/team/editor/EditorContextMenu.test.ts` | +| 5 | Manual: несколько файлов -> вкладки -> создать файл -> удалить файл | — | + +## Критерии готовности + +- [ ] Несколько файлов открыты одновременно +- [ ] Вкладки переключаются, закрываются (X, middle-click) +- [ ] Right-click -> New File, New Folder, Delete +- [ ] Создание файла добавляет в дерево + автоматически открывает +- [ ] Удаление через Trash с confirmation +- [ ] Disambiguation labels для дублирующихся имён + +## Оценка + +- **Надёжность решения: 7/10** -- file operations с правильной валидацией и trash -- надёжный подход. +- **Уверенность: 8/10** -- паттерны файловых операций отработаны. diff --git a/docs/iterations/edit-project/iter-4-search-shortcuts.md b/docs/iterations/edit-project/iter-4-search-shortcuts.md new file mode 100644 index 00000000..03c554a7 --- /dev/null +++ b/docs/iterations/edit-project/iter-4-search-shortcuts.md @@ -0,0 +1,101 @@ +# Итерация 4: Горячие клавиши, поиск, UX polish + +> Зависит от: [Итерация 3](iter-3-multi-tab-crud.md) + +## Цель + +Клавиатурная навигация, Quick Open (Cmd+P), поиск по файлам (Cmd+Shift+F), breadcrumb, иконки файлов, виртуализация дерева. + +## IPC каналы + +| Канал | Описание | +|-------|----------| +| `editor:searchInFiles` | Literal string search, max 100 results, max 1MB/файл | + +## Новые файлы + +| # | Файл | Описание | +|---|------|----------| +| 1 | `src/renderer/components/team/editor/QuickOpenDialog.tsx` | Cmd+P: fuzzy search через `cmdk` | +| 2 | `src/renderer/components/team/editor/SearchInFilesPanel.tsx` | Cmd+Shift+F: результаты поиска | +| 3 | `src/renderer/components/team/editor/EditorBreadcrumb.tsx` | Breadcrumb навигация (кликабельный) | +| 4 | `src/renderer/components/team/editor/EditorShortcutsHelp.tsx` | Модальное окно shortcuts (кнопка ?) | +| 5 | `src/renderer/components/team/editor/fileIcons.ts` | Маппинг расширений на lucide-react иконки/цвета | +| 6 | `src/renderer/hooks/useEditorKeyboardShortcuts.ts` | Все горячие клавиши редактора. CM6 keybindings с `stopPropagation: true` | +| 7 | `src/main/services/editor/FileSearchService.ts` | Search in files (literal, с лимитами) | + +## Изменения в существующих файлах + +| # | Файл | Изменение | +|---|------|-----------| +| 1 | `src/shared/types/editor.ts` | Типы SearchResult | +| 2 | `src/shared/types/api.ts` | `searchInFiles` в EditorAPI | +| 3 | `src/main/ipc/editor.ts` | Handler `editor:searchInFiles` | +| 4 | `src/preload/index.ts` | `editor.searchInFiles` | +| 5 | `src/preload/constants/ipcChannels.ts` | `EDITOR_SEARCH_IN_FILES` | +| 6 | `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | QuickOpen, SearchInFiles, Breadcrumb, shortcuts | +| 7 | `src/renderer/components/team/editor/EditorFileTree.tsx` | Виртуализация через react-virtual + иконки файлов | +| 8 | `src/renderer/components/team/editor/EditorTabBar.tsx` | Иконки файлов на вкладках | + +## 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) + +## 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 + +## Keyboard Scope Isolation (R1) + +**Обязательный шаг**: добавить guard в `useKeyboardShortcuts.ts` для 6 конфликтующих shortcuts: + +```typescript +// В useKeyboardShortcuts.ts: +const editorOpen = useStore(s => s.editorProjectPath !== null); +// В handler — early return для конфликтов при editorOpen === true +``` + +Конкретные конфликты: `Cmd+W` (:155), `Cmd+B` (:271), `Cmd+F` (:241), `Cmd+Shift+[/]` (:177), `Ctrl+Tab` (:81). + +Плюс в `useEditorKeyboardShortcuts.ts` — все CM6 keybindings с `stopPropagation: true` как safety net. + +## Изменения в существующих файлах (доп.) + +| # | Файл | Изменение | +|---|------|-----------| +| 9 | `src/renderer/hooks/useKeyboardShortcuts.ts` | Guard `editorOpen` → early return для 6 конфликтующих shortcuts (R1) | + +## UX-требования + +- `Cmd+Shift+[`/`]` для табов (НЕ `Cmd+[/]` -- это indent/outdent!) +- `Cmd+B` toggle sidebar, width persist в localStorage +- `Cmd+G` go to line (CM6 gotoLine) +- EmptyState показывает шпаргалку shortcuts +- Кнопка `?` в header overlay +- Breadcrumb: каждый сегмент кликабелен -- открывает папку в дереве + +## Тестирование + +| # | Что тестировать | Файл | +|---|----------------|------| +| 1 | `FileSearchService` -- поиск по mock файлам, лимиты | `test/main/services/editor/FileSearchService.test.ts` | +| 2 | `useEditorKeyboardShortcuts` -- обработка горячих клавиш | `test/renderer/hooks/useEditorKeyboardShortcuts.test.ts` | +| 3 | `fileIcons.ts` -- маппинг расширений | `test/renderer/components/team/editor/fileIcons.test.ts` | +| 4 | Виртуализация: benchmark 5000+ файлов, FPS >= 55fps | — | +| 5 | Manual: Cmd+P, Cmd+Shift+F, навигация клавиатурой | — | + +## Критерии готовности + +- [ ] Cmd+P открывает quick open с fuzzy search +- [ ] Cmd+Shift+F показывает результаты поиска по содержимому +- [ ] Все горячие клавиши из таблицы работают +- [ ] Breadcrumb-навигация для текущего файла +- [ ] Иконки файлов по типу в дереве и вкладках +- [ ] File tree виртуализирован, скролл плавный + +## Оценка + +- **Надёжность решения: 7/10** -- виртуализация и search добавляют сложность, но библиотеки проверены. +- **Уверенность: 7/10** -- много нового UI, но каждый компонент изолирован. diff --git a/docs/iterations/edit-project/iter-5-git-watching.md b/docs/iterations/edit-project/iter-5-git-watching.md new file mode 100644 index 00000000..5e965ce8 --- /dev/null +++ b/docs/iterations/edit-project/iter-5-git-watching.md @@ -0,0 +1,96 @@ +# Итерация 5: Git status, file watching, расширенные возможности + +> Зависит от: [Итерация 4](iter-4-search-shortcuts.md) + +## Цель + +Git status в дереве файлов. Live refresh при изменениях на диске. Conflict detection при сохранении. Line wrap toggle. + +## IPC каналы + +| Канал | Описание | +|-------|----------| +| `editor:gitStatus` | `git --no-optional-locks status --porcelain -z --untracked-files=normal --ignore-submodules`, кеш 5 сек | +| `editor:watchDir` | Запуск file watcher (opt-in, НЕ по умолчанию) | +| `editor:change` | Event: файл изменился на диске (main -> renderer) | + +## Новые файлы + +| # | Файл | Описание | +|---|------|----------| +| 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) | +| 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 бейджи в дереве | + +## Изменения в существующих файлах + +| # | Файл | Изменение | +|---|------|-----------| +| 1 | `src/shared/types/editor.ts` | `GitFileStatus`, `EditorFileChangeEvent` | +| 2 | `src/shared/types/api.ts` | `gitStatus`, `onEditorChange` в EditorAPI | +| 3 | `src/main/ipc/editor.ts` | Handlers для git status и file watcher | +| 4 | `src/preload/index.ts` | `editor.gitStatus`, `editor.onEditorChange` (НЕ `onFileChange` — конфликт с существующим `ElectronAPI.onFileChange`) | +| 5 | `src/preload/constants/ipcChannels.ts` | `EDITOR_GIT_STATUS`, `EDITOR_WATCH_DIR`, `EDITOR_CHANGE` | +| 6 | `src/renderer/components/team/editor/EditorFileTree.tsx` | Git status badges | +| 7 | `src/renderer/components/team/editor/CodeMirrorEditor.tsx` | Conflict detection (mtime check) при сохранении | +| 8 | `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | File watcher подписка, auto-refresh, conflict modal | +| 9 | `src/renderer/store/slices/editorSlice.ts` | Git status data, file watcher state | +| 10 | `src/renderer/store/index.ts` | В `initializeNotificationListeners()` добавить подписку `if (api.editor?.onEditorChange)` → обновление дерева/табов при внешних изменениях (guard обязателен — паттерн из всех существующих subscriptions) | +| 11 | `src/main/index.ts` | `mainWindow.on('closed')` → `cleanupEditorState()`. `shutdownServices()` → `cleanupEditorState()` | + +## Security-требования + +1. `editor:gitStatus`: `cwd = activeProjectRoot` (валидный). Не передавать full paths от git без валидации +2. `editor:change`: пути в events могут утечь через symlink -- валидировать перед передачей в renderer (SEC-2) + +## Watcher lifecycle cleanup (macOS: window closed but app alive) + +- `editor:open` — если `activeProjectRoot !== null`, сначала остановить предыдущий watcher и сбросить state (идемпотентный reset). Guard: `if (activeProjectRoot !== null) throw new Error('Another editor is already open')` +- `mainWindow.on('closed')` в `src/main/index.ts` — вызвать `cleanupEditorState()` (экспорт из `editor.ts`): сброс `activeProjectRoot`, остановка watcher. Аналог существующего cleanup для `notificationManager`, `ptyTerminalService` +- `shutdownServices()` — добавить `cleanupEditorState()` рядом с `removeIpcHandlers()` + +## Performance-требования (R4/R5) + +- File watcher opt-in: по умолчанию ВЫКЛЮЧЕН. Toggle "Watch for external changes". По умолчанию ручной refresh (F5) +- `fs.watch({ recursive: true })` + фильтрация (node_modules/.git/dist) + burst coalescing 200ms +- **macOS**: FSEvents reliable (надёжность 9/10) +- **Linux**: inotify (надёжность 6/10). При `ENOSPC` → fallback на polling (5-10 сек). НЕ падать, деградировать +- 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 вывод (безопасный парсинг путей с пробелами) + - `--ignore-submodules` — ускорение на монорепо +- Timeout: 10 секунд (паттерн из GitDiffFallback.ts), AbortSignal +- Graceful degradation: + - Нет git → скрыть git бейджи, "Git not available" в status bar + - Не git-repo → скрыть git бейджи + - Timeout → "Git status unavailable" + кнопка retry + +## UX-требования + +- File changed on disk while open: banner в табе "File changed on disk. [Reload] [Keep mine] [Show diff]" (НЕ перезаписывать молча) +- File deleted on disk while open: banner "File no longer exists on disk. [Close tab]" +- Conflict detection при save: mtime check. Если изменился -- dialog "Overwrite / Cancel / Show diff" +- Line wrap toggle в toolbar + +## Тестирование + +| # | Что тестировать | Файл | +|---|----------------|------| +| 1 | `GitStatusService` -- парсинг `git status --porcelain` вывода | `test/main/services/editor/GitStatusService.test.ts` | +| 2 | `EditorFileWatcher` -- debounce, event types | `test/main/services/editor/EditorFileWatcher.test.ts` | +| 3 | `conflictDetection` -- mtime check логика | `test/main/services/editor/conflictDetection.test.ts` | +| 4 | Manual: изменить файл в внешнем редакторе -> conflict banner | — | + +## Критерии готовности + +- [ ] Git status бейджи (M/U/A) в файловом дереве +- [ ] Auto-refresh при изменениях на диске (при включённом watcher) +- [ ] Conflict detection при сохранении +- [ ] Line wrap toggle + +## Оценка + +- **Надёжность решения: 7/10** (было 6/10) -- R4/R5 ресёрч закрыл ключевые пробелы: `--no-optional-locks`, ENOSPC fallback, burst coalescing. Race conditions остаются, но митигированы. +- **Уверенность: 8/10** (было 7/10) -- паттерны FileWatcher + GitDiffFallback изучены, переиспользование кода подтверждено. diff --git a/docs/iterations/edit-project/research-tasks.md b/docs/iterations/edit-project/research-tasks.md new file mode 100644 index 00000000..aed0f46f --- /dev/null +++ b/docs/iterations/edit-project/research-tasks.md @@ -0,0 +1,147 @@ +# Требуемый ресёрч + +> Зафиксировано после 4-агентного ревью плана. Каждый пункт — пробел в знаниях, без закрытия которого реализация рискованна. + +## R1: Scope isolation горячих клавиш + +**Проблема**: 6 из 12 шорткатов (`Cmd+W`, `Cmd+B`, `Cmd+F`, `Cmd+Shift+[/]`, `Ctrl+Tab`) конфликтуют с глобальными в `useKeyboardShortcuts.ts`. + +**Нужно выяснить**: +- Как CM6 keymaps (через `keymap.of()`) взаимодействуют с глобальным `window.addEventListener('keydown')`? +- Останавливает ли CM6 propagation события? +- Какой паттерн используют VS Code / другие Electron-editors для scope isolation? +- Варианты: guard `isEditorOpen` в глобальном хуке? KeyboardEvent stack? Priority system? + +**Статус**: COMPLETED + +**Результат**: +- Глобальный handler в `useKeyboardShortcuts.ts` использует `window.addEventListener('keydown')` в bubble phase (строка 278) +- CM6 использует bubble phase на `.cm-content`, вызывает `preventDefault()` но НЕ `stopPropagation()` по умолчанию +- CM6 поддерживает `stopPropagation: true` опцию per keybinding +- `ChangeReviewDialog` уже использует capture-phase handler с guard (строки 379-408) +- **Рекомендация: Approach A** — Guard в глобальном handler с флагом `editorOverlayOpen` в store (~20-30 LOC, надёжность 8/10) + - В `useKeyboardShortcuts.ts`: `const editorOpen = useStore(s => s.editorProjectPath !== null);` → early return для конфликтующих shortcuts + - Плюс: добавить `stopPropagation: true` к CM6 keybindings как safety net +- Конкретные конфликты: `Cmd+W` (строка 155), `Cmd+B` (271), `Cmd+F` (241), `Cmd+Shift+[/]` (177), `Ctrl+Tab` (81) + +--- + +## R2: CM6 Compartment + EditorState pooling + +**Проблема**: План хранит Compartments в `useRef` (один экземпляр) и использует для 30+ EditorState в пуле. CM6 может не поддерживать sharing одного Compartment между разными EditorState. + +**Нужно выяснить**: +- Документация CM6: привязан ли Compartment к конкретному EditorState? +- Что происходит при `compartment.of(X)` в extensions для разных EditorState? +- Что происходит при `dispatch({ effects: compartment.reconfigure(Y) })` если другой state в кэше использует тот же Compartment? +- Паттерн из CodeMirrorDiffView: один View + один State — работает, но не pooling. +- Альтернатива: Compartment-per-state в Map (рядом с EditorState). + +**Статус**: COMPLETED + +**Результат**: +- **Compartment — opaque identity token** без внутреннего state. Подтверждено Marijn Haverbeke (автор CM6): "Compartments can be shared without issue" +- Каждый EditorState имеет свой `Map` в config +- `reconfigure()` на одном View не влияет на cached states в пуле +- **Рекомендация: Option A** — Общие Compartment-инстансы для всех states (надёжность 9/10) + - useRef Compartments безопасны для sharing: `readOnlyCompartment.current.of(...)` в extensions для каждого нового EditorState + - При unmount+remount: кешированные states ссылаются на старые Compartments → при remount создать новые Compartments, заново создать EditorState для активного таба + - LRU eviction теряет undo history (ожидаемо), cursor сохраняется через EditorSelection + +--- + +## R3: Store ↔ Component ref bridge (closeEditor + saveAllFiles) + +**Проблема**: `closeEditor()` и `saveAllFiles()` в Zustand action должны работать с `stateCache` и `viewRef` из useRef компонента CodeMirrorEditor. Zustand actions не имеют доступа к React refs. + +**Нужно выяснить**: +- Как существующий код решает аналогичные проблемы (например, terminal cleanup)? +- Варианты: (a) global ref registry; (b) store хранит cleanup callback через `registerCleanup(fn)`; (c) компонент слушает `editorProjectPath === null` в useEffect и делает cleanup сам; (d) zustand subscribe в компоненте. +- Какой вариант минимально инвазивен и надёжен при unmount? + +**Статус**: COMPLETED + +**Результат**: +Существующие паттерны в кодовой базе: +- `MembersJsonEditor` — компонент владеет lifecycle полностью +- `CodeMirrorDiffView` — внешний ref holder +- `changeReviewSlice` — module-level state (строки 5-12) +- `ConfirmDialog` — singleton-регистрация с module-level `globalSetState` +- `ChangeReviewDialog` — компонент оркестрирует + +**Рекомендация: Hybrid C+D** — `editorBridge.ts` module-level singleton (надёжность 9/10): +```typescript +// src/renderer/utils/editorBridge.ts (module-level) +let stateCache: Map | null = null; +let scrollTopCache: Map | null = null; +let viewRef: EditorView | null = null; + +export const editorBridge = { + register(sc, stc, v) { stateCache = sc; scrollTopCache = stc; viewRef = v; }, + unregister() { stateCache = null; scrollTopCache = null; viewRef = null; }, + getContent(filePath) { return stateCache?.get(filePath)?.doc.toString() ?? null; }, + getAllModifiedContent(modifiedFiles) { /* iterate stateCache */ }, + destroy() { viewRef?.destroy(); stateCache?.clear(); scrollTopCache?.clear(); }, +}; +``` +- Компонент вызывает `register()` при mount, `unregister()` при unmount +- Store actions (closeEditor, saveAllFiles) используют `editorBridge.getContent()` / `editorBridge.destroy()` +- `saveAllFiles`: компонент итерирует и вызывает `store.saveFile()` для каждого (паттерн ChangeReviewDialog) +- `discardChanges`: store делает IPC read, компонент применяет через `view.dispatch({ changes })` + +--- + +## R4: fs.watch recursive на Linux + watcher reliability + +**Проблема**: `fs.watch({ recursive: true })` экспериментальный на Linux в Node.js 18. Может тихо не работать. Нет fallback. + +**Нужно выяснить**: +- Какую версию Node.js использует Electron 40? Поддерживается ли `recursive: true` на Linux? +- Существующий `FileWatcher.ts` в проекте: использует ли он `recursive: true`? Есть ли fallback? +- Альтернативы: chokidar (но добавляет зависимость), polling, non-recursive watch + manual traversal. +- `max_user_watches` лимит inotify — как обойти? +- macOS FSEvents: coalescing events — как существующий FileWatcher решает это? + +**Статус**: COMPLETED + +**Результат**: +- **Electron 40 = Node.js 24 (LTS)** — `recursive: true` работает на macOS (FSEvents, надёжность 9/10) и Linux (inotify, надёжность 6/10) +- Существующий `FileWatcher.ts` (1098 строк) — зрелый watcher с debounce (строки 1060-1074), recovery (424-457), catch-up scan (992-1051) +- **НЕ добавлять chokidar** — использовать паттерны из FileWatcher.ts +- macOS: fs.watch recursive reliable (FSEvents), burst coalescing для git checkout сценариев +- Linux: `ENOSPC` → fallback на polling (5-10 секунд интервал) +- `max_user_watches` лимит inotify: при ENOSPC не падать, а деградировать до polling +- **Рекомендация**: EditorFileWatcher ~250-300 LOC (вместо первоначальных ~60 LOC), включая: + - Burst coalescing (200ms debounce + batch) + - ENOSPC fallback to polling + - Фильтрация: node_modules/.git/dist + - Graceful stop/restart + +--- + +## R5: Git CLI availability & performance + +**Проблема**: `git status --porcelain` без проверки наличия git. На больших монорепо — десятки секунд. Non-git проекты не обработаны. + +**Нужно выяснить**: +- Есть ли в проекте утилита проверки наличия git? (проверить существующие git-related сервисы) +- `git status --porcelain` performance: `--untracked-files=no` ускоряет? `--ignore-submodules`? +- Timeout стратегия: AbortSignal + child_process? +- Graceful degradation: что показывать если git недоступен / не git-repo? +- `.git/index.lock` — как обрабатывать concurrent git operations? + +**Статус**: COMPLETED + +**Результат**: +- **`isGitRepo()`** уже есть в `GitDiffFallback.ts` (строки 118-133) — переиспользовать +- Оптимальная команда: `git --no-optional-locks status --porcelain -z --untracked-files=normal --ignore-submodules` + - `--no-optional-locks` — критично, предотвращает `.git/index.lock` конфликты + - `-z` — NUL-separated вывод (безопасный парсинг путей с пробелами) + - `--ignore-submodules` — ускорение на монорепо +- Timeout: 10 секунд (паттерн из GitDiffFallback.ts), AbortSignal +- Добавить `'conflict'` статус в `GitFileStatus` для merge conflicts (`UU`, `AA`, `DD` коды) +- Graceful degradation: проверить git available → проверить is repo → timeout handling + - Нет git: скрыть git бейджи, показать "Git not available" в status bar + - Не git-repo: скрыть git бейджи + - Timeout: показать "Git status unavailable" + кнопка retry +- **GitStatusService ~200-250 LOC**, EditorFileWatcher **~250-300 LOC** diff --git a/docs/iterations/edit-project/wireframes-draft.md b/docs/iterations/edit-project/wireframes-draft.md new file mode 100644 index 00000000..1cd25b61 --- /dev/null +++ b/docs/iterations/edit-project/wireframes-draft.md @@ -0,0 +1,171 @@ +# Wireframes (черновик, пересмотреть позже) + +> Статус: DRAFT — требует пересмотра и финализации перед реализацией. + +## 1. Main state (файл открыт) +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ [←] Open in Editor — /Users/belief/project [?] [×] │ +├──────────────────────┬──────────────────────────────────────────────────────┤ +│ 🔍 Filter files... │ [● index.ts] [ App.tsx ] [ utils.ts] [×] │ +│ │ ─────────────────────────────────────────────────── │ +│ ▼ src/ │ src / renderer / components / App.tsx │ +│ ▼ renderer/ │ ─────────────────────────────────────────────────── │ +│ ▼ components/ │ 1 │ import React from 'react'; │ +│ ▸ chat/ │ 2 │ import { useStore } from '../store'; │ +│ ▸ common/ │ 3 │ │ +│ ● App.tsx │ 4 │ export const App = () => { │ +│ index.ts │ 5 │ const theme = useStore(s => s.theme); │ +│ ▸ hooks/ │ 6 │ return ( │ +│ ▸ store/ │ 7 │
│ +│ ▸ utils/ │ 8 │ │ +│ ▸ main/ │ 9 │
│ +│ ▸ shared/ │ 10 │ ); │ +│ ▸ test/ │ 11 │ }; │ +│ ▸ docs/ │ 12 │ │ +│ package.json │ │ +│ tsconfig.json │ │ +│ ├──────────────────────────────────────────────────────│ +│ │ Ln 5, Col 12 │ TypeScript │ UTF-8 │ Spaces: 2 │ LF │ +└──────────────────────┴──────────────────────────────────────────────────────┘ +``` + +## 2. Empty state (нет открытых файлов) +``` +┌──────────────────────┬──────────────────────────────────────────────────────┐ +│ 🔍 Filter files... │ │ +│ │ │ +│ ▼ src/ │ No file is open │ +│ ▸ main/ │ │ +│ ▸ renderer/ │ Keyboard Shortcuts │ +│ ▸ shared/ │ ───────────────── │ +│ ▸ test/ │ ⌘P Quick Open │ +│ package.json │ ⌘S Save File │ +│ │ ⌘⇧F Search in Files │ +│ │ ⌘W Close Tab │ +│ │ ⌘B Toggle Sidebar │ +│ │ ⌘G Go to Line │ +│ │ Esc Close Editor │ +│ │ │ +└──────────────────────┴──────────────────────────────────────────────────────┘ +``` + +## 3. Unsaved changes confirm dialog +``` +┌──────────────────────────────────────────────────┐ +│ │ +│ ⚠ You have unsaved changes in 3 files: │ +│ │ +│ • index.ts │ +│ • App.tsx │ +│ • utils.ts │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌────────┐ │ +│ │ Save All & │ │ Discard & │ │ Cancel │ │ +│ │ Close │ │ Close │ │ │ │ +│ └──────────────┘ └──────────────┘ └────────┘ │ +│ │ +└──────────────────────────────────────────────────┘ +``` + +## 4. Context menu на директории +``` +│ ▼ components/ │ +│ ▸ chat/ │ +│ ▸ common/ ─────┤ +│ App.tsx │ New File... │ +│ index.ts │ New Folder... │ +│ ▸ hooks/ │────────────────────│ +│ │ Delete │ +│ │ Copy Path │ +│ │ Reveal in Finder │ +│ └────────────────────┘ +``` + +## 5. Quick Open (Cmd+P) +``` +┌──────────────────────────────────────────────────┐ +│ 🔍 app.tsx │ +├──────────────────────────────────────────────────┤ +│ ● App.tsx src/renderer/components │ +│ AppShell.tsx src/renderer/components │ +│ api.ts src/shared/types │ +│ atomicWrite.ts src/main/utils │ +│ │ +│ 4 results │ +└──────────────────────────────────────────────────┘ +``` + +## 6. Search in Files (Cmd+Shift+F) +``` +┌──────────────────────┬──────────────────────────────────────────────────────┐ +│ SEARCH IN FILES │ [ index.ts ] [● App.tsx ] │ +│ 🔍 useStore [Aa] │ ─────────────────────────────────────────────────── │ +│ ──────────────────── │ 1 │ import React from 'react'; │ +│ 12 results in 8 files│ 5 │ const theme = useStore(s => s.theme); ◄── │ +│ │ │ +│ ▼ src/renderer/ │ │ +│ App.tsx:5 │ │ +│ const theme = │ │ +│ useStore(s => │ │ +│ store/index.ts:12 │ │ +│ export const │ │ +│ useStore = ... │ │ +│ ▼ src/main/ │ │ +│ ... │ │ +└──────────────────────┴──────────────────────────────────────────────────────┘ +``` + +## 7. Binary / Error / Large file states +``` +Binary file: +┌──────────────────────────────────────────────────┐ +│ │ +│ 📄 logo.png │ +│ PNG Image • 245 KB │ +│ │ +│ [Open in System Viewer] [Close Tab] │ +│ │ +└──────────────────────────────────────────────────┘ + +Error state: +┌──────────────────────────────────────────────────┐ +│ │ +│ ⚠ Cannot read file │ +│ Permission denied (EACCES) │ +│ │ +│ [Retry] [Close Tab] │ +│ │ +└──────────────────────────────────────────────────┘ + +Large file (2-5MB): +┌──────────────────────────────────────────────────┐ +│ ⚠ File too large for editing (3.2 MB) │ +│ Showing first 100 lines (read-only preview) │ +│──────────────────────────────────────────────────│ +│ 1 │ // This is a large generated file... │ +│ 2 │ ... │ +│ 100 │ ... │ +│──────────────────────────────────────────────────│ +│ [Open in External Editor] │ +└──────────────────────────────────────────────────┘ +``` + +## 8. Git status badges + conflict banner +``` +File tree with git status: +│ ▼ src/ │ +│ ▼ renderer/ │ +│ M App.tsx │ ← M = modified (amber) +│ U newFile.ts │ ← U = untracked (green) +│ A staged.ts │ ← A = staged (blue) +│ index.ts │ + +Conflict banner (file changed on disk while open): +┌──────────────────────────────────────────────────────────────┐ +│ ⚠ File changed on disk [Reload] [Keep Mine] [Show Diff] │ +├──────────────────────────────────────────────────────────────┤ +│ 1 │ import React from 'react'; │ +│ 2 │ ... │ +└──────────────────────────────────────────────────────────────┘ +``` diff --git a/src/main/services/discovery/WorktreeGrouper.ts b/src/main/services/discovery/WorktreeGrouper.ts index 685c92a7..48ba6c30 100644 --- a/src/main/services/discovery/WorktreeGrouper.ts +++ b/src/main/services/discovery/WorktreeGrouper.ts @@ -135,33 +135,35 @@ export class WorktreeGrouper { const repositoryGroups: RepositoryGroup[] = []; for (const [groupId, group] of repoGroups) { - const worktrees: Worktree[] = group.projects.map((project) => { - const branch = group.branches.get(project.id) ?? null; - const isMainWorktree = !gitIdentityResolver.isWorktree(project.path); - // Use filtered sessions instead of raw sessions - const filteredSessions = projectFilteredSessions.get(project.id) ?? []; - // Detect worktree source for badge display - const source = gitIdentityResolver.detectWorktreeSource(project.path); - // Use source-aware display name generation - const displayName = gitIdentityResolver.getWorktreeDisplayName( - project.path, - source, - branch, - isMainWorktree - ); + const worktrees: Worktree[] = await Promise.all( + group.projects.map(async (project) => { + const branch = group.branches.get(project.id) ?? null; + const isMainWorktree = !(await gitIdentityResolver.isWorktree(project.path)); + // Use filtered sessions instead of raw sessions + const filteredSessions = projectFilteredSessions.get(project.id) ?? []; + // Detect worktree source for badge display + const source = await gitIdentityResolver.detectWorktreeSource(project.path); + // Use source-aware display name generation + const displayName = await gitIdentityResolver.getWorktreeDisplayName( + project.path, + source, + branch, + isMainWorktree + ); - return { - id: project.id, - path: project.path, - name: displayName, - gitBranch: branch ?? undefined, - isMainWorktree, - source, - sessions: filteredSessions, - createdAt: project.createdAt, - mostRecentSession: project.mostRecentSession, - }; - }); + return { + id: project.id, + path: project.path, + name: displayName, + gitBranch: branch ?? undefined, + isMainWorktree, + source, + sessions: filteredSessions, + createdAt: project.createdAt, + mostRecentSession: project.mostRecentSession, + }; + }) + ); // Filter out worktrees with 0 visible sessions const nonEmptyWorktrees = worktrees.filter((wt) => wt.sessions.length > 0); diff --git a/src/main/services/parsing/GitIdentityResolver.ts b/src/main/services/parsing/GitIdentityResolver.ts index 80608dd6..bed2c1f3 100644 --- a/src/main/services/parsing/GitIdentityResolver.ts +++ b/src/main/services/parsing/GitIdentityResolver.ts @@ -10,6 +10,9 @@ * Git worktree detection: * - Main repo: .git is a directory * - Worktree: .git is a file containing "gitdir: /path/to/main/.git/worktrees/" + * + * All filesystem operations use fs.promises to avoid blocking the main process event loop. + * Results are cached with a short TTL to avoid redundant reads during batch operations. */ import { @@ -27,12 +30,31 @@ import { import { type RepositoryIdentity, type WorktreeSource } from '@main/types'; import { createLogger } from '@shared/utils/logger'; import * as crypto from 'crypto'; -import * as fs from 'fs'; +import * as fsp from 'fs/promises'; import * as path from 'path'; const logger = createLogger('Service:GitIdentityResolver'); +interface CacheEntry { + value: T; + expiry: number; +} + +/** Check if a path exists on the filesystem (async). */ +async function fileExists(filePath: string): Promise { + try { + await fsp.access(filePath); + return true; + } catch { + return false; + } +} + class GitIdentityResolver { + private identityCache = new Map>(); + private branchCache = new Map>(); + private static readonly CACHE_TTL_MS = 60_000; + /** * Resolve repository identity from a project path. * @@ -48,66 +70,80 @@ class GitIdentityResolver { * @returns RepositoryIdentity or null if not a git repo */ async resolveIdentity(projectPath: string): Promise { + const cached = this.identityCache.get(projectPath); + if (cached && cached.expiry > Date.now()) { + return cached.value; + } + + const result = await this.resolveIdentityUncached(projectPath); + this.identityCache.set(projectPath, { + value: result, + expiry: Date.now() + GitIdentityResolver.CACHE_TTL_MS, + }); + return result; + } + + private async resolveIdentityUncached(projectPath: string): Promise { try { const gitPath = path.join(projectPath, '.git'); - // First, try filesystem-based resolution - if (fs.existsSync(gitPath)) { - const stats = fs.statSync(gitPath); + let stats: Awaited>; + try { + stats = await fsp.stat(gitPath); + } catch { + // .git doesn't exist — fallback to path heuristics + return this.resolveIdentityFromPath(projectPath); + } - let mainGitDir: string; + let mainGitDir: string; - if (stats.isFile()) { - // This is a worktree - parse the .git file to find main repo - const gitFileContent = fs.readFileSync(gitPath, 'utf-8').trim(); - const gitDirMatch = /^gitdir:\s*(\S[^\r\n]*)$/m.exec(gitFileContent); + if (stats.isFile()) { + // This is a worktree - parse the .git file to find main repo + const gitFileContent = (await fsp.readFile(gitPath, 'utf-8')).trim(); + const gitDirMatch = /^gitdir:\s*(\S[^\r\n]*)$/m.exec(gitFileContent); - if (!gitDirMatch) { - logger.warn(`Invalid .git file format at ${gitPath}`); - return this.resolveIdentityFromPath(projectPath); - } - - let worktreeGitDir = gitDirMatch[1].trim(); - - // Handle relative paths in gitdir (resolve relative to the .git file location) - if (!path.isAbsolute(worktreeGitDir)) { - worktreeGitDir = path.resolve(projectPath, worktreeGitDir); - } - - mainGitDir = this.extractMainGitDir(worktreeGitDir); - } else if (stats.isDirectory()) { - mainGitDir = gitPath; - } else { + if (!gitDirMatch) { + logger.warn(`Invalid .git file format at ${gitPath}`); return this.resolveIdentityFromPath(projectPath); } - // Normalize the path to handle symlinks (e.g., /tmp -> /private/var/folders) - // This ensures all worktrees of the same repo get the same ID - try { - mainGitDir = fs.realpathSync(mainGitDir); - } catch { - // If realpath fails (e.g., path doesn't exist), use as-is + let worktreeGitDir = gitDirMatch[1].trim(); + + // Handle relative paths in gitdir (resolve relative to the .git file location) + if (!path.isAbsolute(worktreeGitDir)) { + worktreeGitDir = path.resolve(projectPath, worktreeGitDir); } - // Extract remote URL from config - const remoteUrl = this.getRemoteUrl(mainGitDir); - - // Generate consistent repository ID based on the CANONICAL main git directory - const repoId = this.generateRepoId(remoteUrl, mainGitDir); - - // Extract repository name from path or remote URL - const repoName = this.extractRepoName(remoteUrl, mainGitDir); - - return { - id: repoId, - remoteUrl: remoteUrl ?? undefined, - mainGitDir, - name: repoName, - }; + mainGitDir = this.extractMainGitDir(worktreeGitDir); + } else if (stats.isDirectory()) { + mainGitDir = gitPath; + } else { + return this.resolveIdentityFromPath(projectPath); } - // Fallback: path doesn't exist, use heuristic resolution - return this.resolveIdentityFromPath(projectPath); + // Normalize the path to handle symlinks (e.g., /tmp -> /private/var/folders) + // This ensures all worktrees of the same repo get the same ID + try { + mainGitDir = await fsp.realpath(mainGitDir); + } catch { + // If realpath fails (e.g., path doesn't exist), use as-is + } + + // Extract remote URL from config + const remoteUrl = await this.getRemoteUrl(mainGitDir); + + // Generate consistent repository ID based on the CANONICAL main git directory + const repoId = this.generateRepoId(remoteUrl, mainGitDir); + + // Extract repository name from path or remote URL + const repoName = this.extractRepoName(remoteUrl, mainGitDir); + + return { + id: repoId, + remoteUrl: remoteUrl ?? undefined, + mainGitDir, + name: repoName, + }; } catch (error) { logger.error(`Error resolving git identity for ${projectPath}:`, error); // Try fallback even on error @@ -223,7 +259,7 @@ class GitIdentityResolver { * Worktrees have a .git file, main repos have a .git directory. * Uses path heuristics if filesystem is not available (for deleted worktrees). */ - isWorktree(projectPath: string): boolean { + async isWorktree(projectPath: string): Promise { // First, try path-based heuristics (works for deleted worktrees) const parts = projectPath.split(path.sep).filter(Boolean); @@ -257,10 +293,8 @@ class GitIdentityResolver { // Fallback: check filesystem if available try { const gitPath = path.join(projectPath, '.git'); - if (fs.existsSync(gitPath)) { - const stats = fs.statSync(gitPath); - return stats.isFile(); - } + const stats = await fsp.stat(gitPath); + return stats.isFile(); } catch { // Ignore errors - filesystem might not be available } @@ -301,15 +335,17 @@ class GitIdentityResolver { * @param gitDir - Path to the .git directory * @returns Remote URL or null if not found */ - private getRemoteUrl(gitDir: string): string | null { + private async getRemoteUrl(gitDir: string): Promise { try { const configPath = path.join(gitDir, 'config'); - if (!fs.existsSync(configPath)) { + + let configContent: string; + try { + configContent = await fsp.readFile(configPath, 'utf-8'); + } catch { return null; } - const configContent = fs.readFileSync(configPath, 'utf-8'); - // Parse git config to find [remote "origin"] section const lines = configContent.split(/\r?\n/); let inOriginRemote = false; @@ -413,19 +449,35 @@ class GitIdentityResolver { * @returns Branch name or null */ async getBranch(projectPath: string): Promise { + const cached = this.branchCache.get(projectPath); + if (cached && cached.expiry > Date.now()) { + return cached.value; + } + + const result = await this.getBranchUncached(projectPath); + this.branchCache.set(projectPath, { + value: result, + expiry: Date.now() + GitIdentityResolver.CACHE_TTL_MS, + }); + return result; + } + + private async getBranchUncached(projectPath: string): Promise { try { const gitPath = path.join(projectPath, '.git'); - if (!fs.existsSync(gitPath)) { + let stats: Awaited>; + try { + stats = await fsp.stat(gitPath); + } catch { return null; } - const stats = fs.statSync(gitPath); let headPath: string; if (stats.isFile()) { // Worktree - read .git file to find the HEAD location - const gitFileContent = fs.readFileSync(gitPath, 'utf-8').trim(); + const gitFileContent = (await fsp.readFile(gitPath, 'utf-8')).trim(); const gitDirMatch = /^gitdir:\s*(\S[^\r\n]*)$/.exec(gitFileContent); if (!gitDirMatch) { @@ -438,12 +490,13 @@ class GitIdentityResolver { headPath = path.join(gitPath, 'HEAD'); } - if (!fs.existsSync(headPath)) { + let headContent: string; + try { + headContent = (await fsp.readFile(headPath, 'utf-8')).trim(); + } catch { return null; } - const headContent = fs.readFileSync(headPath, 'utf-8').trim(); - // Check if HEAD is a symbolic ref (branch) const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(headContent); if (refMatch) { @@ -476,7 +529,7 @@ class GitIdentityResolver { * @param projectPath - The filesystem path to check * @returns WorktreeSource identifier */ - detectWorktreeSource(projectPath: string): WorktreeSource { + async detectWorktreeSource(projectPath: string): Promise { const parts = projectPath.split(path.sep).filter(Boolean); // Pattern: vibe-kanban @@ -518,13 +571,8 @@ class GitIdentityResolver { // Check if it's a standard git repo (only if filesystem exists) // For deleted repos, we'll return 'git' as fallback since we can't verify - try { - const gitPath = path.join(projectPath, '.git'); - if (fs.existsSync(gitPath)) { - return 'git'; - } - } catch { - // Ignore errors - filesystem might not be available + if (await fileExists(path.join(projectPath, '.git'))) { + return 'git'; } // Default to 'git' for paths that don't match known patterns @@ -542,12 +590,12 @@ class GitIdentityResolver { * @param isMainWorktree - Whether this is the main worktree * @returns Display name for the worktree */ - getWorktreeDisplayName( + async getWorktreeDisplayName( projectPath: string, source: WorktreeSource, branch: string | null, isMainWorktree: boolean - ): string { + ): Promise { const parts = projectPath.split(path.sep).filter(Boolean); switch (source) { @@ -626,7 +674,7 @@ class GitIdentityResolver { return branch ?? 'main'; } // For non-main git worktrees, try to get the worktree name from .git file - return this.getGitWorktreeName(projectPath) ?? branch ?? parts[parts.length - 1]; + return (await this.getGitWorktreeName(projectPath)) ?? branch ?? parts[parts.length - 1]; case 'unknown': default: @@ -645,15 +693,20 @@ class GitIdentityResolver { * @param projectPath - The filesystem path * @returns Worktree name or null */ - private getGitWorktreeName(projectPath: string): string | null { + private async getGitWorktreeName(projectPath: string): Promise { try { const gitPath = path.join(projectPath, '.git'); - if (!fs.existsSync(gitPath)) return null; - const stats = fs.statSync(gitPath); + let stats: Awaited>; + try { + stats = await fsp.stat(gitPath); + } catch { + return null; + } + if (!stats.isFile()) return null; - const content = fs.readFileSync(gitPath, 'utf-8'); + const content = await fsp.readFile(gitPath, 'utf-8'); const match = /gitdir:\s*(\S[^\r\n]*)/.exec(content); if (!match) return null;