feat: enhance project editor with file management, Git integration, and UI improvements

- Introduced `EditorFileWatcher` for live file change detection and `GitStatusService` for displaying Git status in the file tree.
- Added context menu for file operations (create, delete) and implemented multi-tab support for the editor.
- Enhanced user experience with keyboard shortcuts, search functionality, and breadcrumb navigation.
- Updated IPC channels for file operations and integrated conflict detection during file saves.
- Improved performance with file watcher optimizations and virtualized file tree rendering.
This commit is contained in:
iliya 2026-02-28 13:35:22 +02:00
parent 7192ca68fb
commit d7fc71a5e6
13 changed files with 1300 additions and 133 deletions

View file

@ -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))

View file

@ -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<T> с render-props (рефакторинг из ReviewFileTree)
```
@ -214,6 +217,8 @@ export interface EditorSlice {
editorFileTreeLoading: boolean;
editorFileTreeError: string | null;
editorExpandedDirs: Set<string>; // Сохраняется при re-open (H7)
openEditor: (projectPath: string) => Promise<void>;
closeEditor: () => void;
// closeEditor() выполняет полный cleanup:
@ -247,7 +252,7 @@ export interface EditorSlice {
// В store -- только dirty flags, loading и статусы сохранения.
// ═══════════════════════════════════════════════════
editorFileLoading: Record<string, boolean>; // per-file loading indicator
editorModifiedFiles: Set<string>; // dirty markers (НЕ содержимое!)
editorModifiedFiles: Record<string, boolean>; // dirty markers (НЕ содержимое!). Record вместо Set — Zustand не отслеживает мутации Set
editorSaving: Record<string, boolean>;
editorSaveError: Record<string, string>;
@ -259,7 +264,7 @@ export interface EditorSlice {
saveAllFiles: (getContent: (filePath: string) => string | null) => Promise<void>;
// 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<string, EditorState> | null = null;
let scrollTopCache: Map<string, number> | null = null;
let activeView: EditorView | null = null;
export const editorBridge = {
/** Вызывается CodeMirrorEditor при mount */
register(sc: Map<string, EditorState>, stc: Map<string, number>, 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<string>): Map<string, string> {
const result = new Map<string, string>();
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<Compartment, Extension> в 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

View file

@ -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 |

View file

@ -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<T>()` | `review.ts:133-145` | `src/main/ipc/ipcWrapper.ts` | ~15 |
## Детали каждого рефакторинга
### R1: `buildTree<T>()` — Generic tree builder
**NB**: `ReviewFileTree` работает с `FileChangeSummary` (имеет `status`, `additions`, `deletions`), а editor использует `FileTreeEntry` (имеет `size`, `children`). `buildTree<T>()` должен быть generic по типу node, принимая `getPath: (item: T) => string` и `isDirectory: (item: T) => boolean` как параметры.
```typescript
// src/renderer/utils/fileTreeBuilder.ts
function buildTree<T>(
items: T[],
getPath: (item: T) => string,
isDirectory: (item: T) => boolean
): TreeNode<T>[]
```
### 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<T>(op: string, fn: () => Promise<T>): Promise<IpcResult<T>> {
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`

View file

@ -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<T>()` (рефакторинг из ReviewFileTree) |
| 8 | `src/renderer/utils/codemirrorLanguages.ts` | `getSyncLanguageExtension()` (рефакторинг) |
| 9 | `src/renderer/utils/codemirrorTheme.ts` | `baseEditorTheme` (рефакторинг) |
| 10 | `src/renderer/components/common/FileTree.tsx` | Generic FileTree<T> с 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<string, Promise<ReadFileResult>>` для 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** -- самый понятный этап, минимум неизвестных.

View file

@ -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<tabId, EditorState>), 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<string, boolean>` (dirty flags — Record вместо Set, т.к. Zustand не отслеживает мутации Set)
- Dirty flag через debounced `EditorView.updateListener` (300ms)
- Гранулярные Zustand-селекторы: FileTreePanel не подписывается на tabs/content
- EditorState pooling: один EditorView, Map<tabId, EditorState> в 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 требует тестирования.

View file

@ -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** -- паттерны файловых операций отработаны.

View file

@ -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, но каждый компонент изолирован.

View file

@ -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 изучены, переиспользование кода подтверждено.

View file

@ -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<Compartment, Extension>` в 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<string, EditorState> | null = null;
let scrollTopCache: Map<string, number> | 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**

View file

@ -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 │ <div className={theme}>
│ ▸ utils/ │ 8 │ <Router />
│ ▸ main/ │ 9 │ </div>
│ ▸ 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 │ ... │
└──────────────────────────────────────────────────────────────┘
```

View file

@ -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);

View file

@ -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/<name>"
*
* 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<T> {
value: T;
expiry: number;
}
/** Check if a path exists on the filesystem (async). */
async function fileExists(filePath: string): Promise<boolean> {
try {
await fsp.access(filePath);
return true;
} catch {
return false;
}
}
class GitIdentityResolver {
private identityCache = new Map<string, CacheEntry<RepositoryIdentity | null>>();
private branchCache = new Map<string, CacheEntry<string | null>>();
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<RepositoryIdentity | null> {
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<RepositoryIdentity | null> {
try {
const gitPath = path.join(projectPath, '.git');
// First, try filesystem-based resolution
if (fs.existsSync(gitPath)) {
const stats = fs.statSync(gitPath);
let stats: Awaited<ReturnType<typeof fsp.stat>>;
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<boolean> {
// 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<string | null> {
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<string | null> {
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<string | null> {
try {
const gitPath = path.join(projectPath, '.git');
if (!fs.existsSync(gitPath)) {
let stats: Awaited<ReturnType<typeof fsp.stat>>;
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<WorktreeSource> {
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<string> {
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<string | null> {
try {
const gitPath = path.join(projectPath, '.git');
if (!fs.existsSync(gitPath)) return null;
const stats = fs.statSync(gitPath);
let stats: Awaited<ReturnType<typeof fsp.stat>>;
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;